<?php if ( ! defined('BASEPATH')) exit('No direct script access allowed');
/**************************************************************************
 *
 *   Copyright 2010 American Public Media Group
 *
 *   This file is part of AIR2.
 *
 *   AIR2 is free software: you can redistribute it and/or modify
 *   it under the terms of the GNU General Public License as published by
 *   the Free Software Foundation, either version 3 of the License, or
 *   (at your option) any later version.
 *
 *   AIR2 is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *   GNU General Public License for more details.
 *
 *   You should have received a copy of the GNU General Public License
 *   along with AIR2.  If not, see <http://www.gnu.org/licenses/>.
 *
 *************************************************************************/

/**
 * AirQuery Library
 *
 * Contains method to execute a doctrine query, format the results and
 * metadata, and apply other cleaning to the data.
 *
 * @author rcavis
 * @package default
 */
class AirQuery {
    public $column_blacklist = array();
    public $column_whitelist = array();
    public $extra_field_defs = array();
    public $sort_def = array();
    public $flatten = array();


    /**
     * Get cleaned and formatted data (in $data['radix']) from a query,
     * optionally adding metadata (in $data['metaData']).  $params have the
     * following format:
     *     'add_meta' => (boolean) include metadata with results
     *     'return_list' => (boolean) return 'radix' as list of items
     *     'blacklist' => (array) columns to blacklist from results
     *     'whitelist' => (array) columns to whitelist in results
     *     'extra' => (array) extra field definitions to add to results
     *     'flatten' => (string) flatten a relation
     *
     * @param AIR2_Query $q      (reference) query to execute
     * @param unknown $params
     * @return associative-array the fetched data, or false if not found
     */
    public function execute_query(&$q, $params) {
        if (isset($params['blacklist']) && is_array($params['blacklist'])) {
            $this->column_blacklist = array_merge($this->column_blacklist, $params['blacklist']);
        }
        if (isset($params['whitelist']) && is_array($params['whitelist'])) {
            $this->column_whitelist = array_merge($this->column_whitelist, $params['whitelist']);
        }
        if (isset($params['extra']) && is_array($params['extra'])) {
            $this->extra_field_defs = array_merge($this->extra_field_defs, $params['extra']);
        }
        if (isset($params['sort']) && is_array($params['sort'])) {
            $this->sort_def = $params['sort'];
        }
        if (isset($params['flatten'])) {
            if (is_array($params['flatten'])) {
                $this->flatten = array_merge($this->flatten, $params['flatten']);
            }
            else {
                $this->flatten[] = $params['flatten'];
            }
        }

        $add_meta = (isset($params['add_meta'])) ? $params['add_meta'] : true;
        $return_list = (isset($params['return_list'])) ? $params['return_list'] : true;

        // fetch records --- hydrate array to prevent extra fields being added
        $radix = $q->execute(array(), Doctrine_Core::HYDRATE_ARRAY);

        if (!$return_list && count($radix) == 0) {
            return false; // specific resource not found!
        }

        // add uuid column to the whitelist, to make sure we don't clean it
        $idprop = air2_get_model_uuid_col($q->getRoot()->getComponentName());
        $this->column_whitelist []= $idprop;

        // check for mapped uuid column
        $mapped_id = null;
        if (strpos($idprop, ':')) {
            $mapped_id = $idprop;
        }

        // process the radix
        $this->process_radix($radix, $mapped_id);

        // format the output
        $out = $this->format_output($radix, $return_list, $q, $add_meta);

        // clear the params
        $this->column_blacklist = array();
        $this->column_whitelist = array();
        $this->extra_field_defs = array();
        $this->sort_def = array();

        return $out;
    }


    /**
     * Helper function to process an array of data, cleaning hidden fields and
     * applying the whitelist/blacklist.  If the PK's of the radix are mapped
     * fields, pass in $map_id to apply that mapped id.  It should have a form
     * like "UserOrg:user_uuid" --> "ForeignObject:key_field".
     *
     * @param array   $radix  (reference) the data to work with
     * @param string  $map_id (optional) the mapped id field
     */
    public function process_radix(&$radix, $map_id=null) {
        // flatten relations
        foreach ($this->flatten as $rel_name) {
            foreach ($radix as &$obj) {
                if (isset($obj[$rel_name]) && is_array($obj[$rel_name])) {
                    $rel_obj = $obj[$rel_name];

                    // add each related column to the radix
                    foreach ($rel_obj as $key => $val) {
                        $new_key = "$rel_name:$key";
                        if (!isset($obj[$new_key])) {
                            $obj[$new_key] = $val;
                        }
                    }
                }
            }
        }

        // clean data
        foreach ($radix as &$obj) {
            $this->_clean_record($obj);
        }

        // process a "mapped" id (contains ":")
        if ($map_id) {
            // add it to the base level of each record
            $split = explode(':', $map_id);
            foreach ($radix as &$rec) {
                $rec[$map_id] = $rec[$split[0]][$split[1]];
            }
        }
    }


    /**
     * Helper function to format a proccesed radix, optionally using the query
     * object to find out the total count and generate the metadata.  If you
     * request a list, or metadata, you MUST pass in the query object.
     *
     * @param array   $radix       (reference) the data to work with
     * @param boolean $return_list return 'radix' as list or object
     * @param AIR2_Query $q           (optional, reference)
     * @param boolean $meta        (optional) generate metadata
     * @return array in the form ('radix' => ..., 'metadata' => ..., )
     */
    public function format_output(&$radix, $return_list, &$q=null, $meta=false) {
        $data = array();

        // set radix to list or object, depending on mode
        if ($return_list) {
            $data['radix'] = $radix;
            if ($q) $data['total'] = $q->count();
        }
        else {
            $data['radix'] = $radix[0]; // single resource
        }

        if ($meta && $q) {
            $data['metaData'] = $this->_create_meta($q); // add metadata
        }

        return $data;
    }


    /**
     * Helper function to recursively clean the columns in result sets.
     *
     * @param array   $obj (reference) list of result sets to be modified
     */
    protected function _clean_record(&$obj) {
        foreach ($obj as $fld => $val) {
            if (is_array($val)) {
                $this->_clean_record($obj[$fld]); // recursive
            }
            elseif ($this->_column_should_remove($fld)) {
                unset($obj[$fld]);
            }
        }
    }


    /**
     * Returns a metaData object suitable for use by the ExtJS framework.  Note
     * that since this scans the components of the query, it will miss out on
     * any custom "select" clauses that execute native SQL.
     *
     * @param AIR2_Query $q          (reference) the query to build metadata for
     * @param boolean $add_paging (optional) adds paging parameters to metadata
     * @return associative-array the metadata object
     */
    protected function _create_meta(&$q, $add_paging=true) {
        $metadata = array();
        $metadata['root'] = 'radix';
        $metadata['totalProperty'] = 'total';
        $metadata['successProperty'] = 'success';
        $metadata['messageProperty'] = 'message';
        $metadata['fields'] = $this->_get_fields($q);

        $alias = $q->getRootAlias(); // IMPORTANT! GET THIS BEFORE ->getRoot()
        $id = air2_get_model_uuid_col($q->getRoot()->getComponentName());
        if ($id) {
            $metadata['idProperty'] = $id;

            // add another field if the uuid isn't in the root model
            if (strpos($id, ':')) {
                $metadata['fields'] []= array(
                    'name' => $id,
                );
            }
        }

        if ($add_paging) {
            $order = $q->getDqlPart('orderby');
            $start = $q->getDqlPart('offset');
            $limit = $q->getDqlPart('limit');

            // paging and sort info
            if (isset($order[0])) {
                if (count($this->sort_def) > 0) {
                    // TODO: what should multi-sort look like?
                    $metadata['sortInfo'] = array(
                        'field' => $this->sort_def[0][0],
                        'direction' => $this->sort_def[0][2],
                    );
                }
                else {
                    // try to manually find the sort info
                    $parts = explode(' ', $order[0]);
                    if (count($parts == 2)) {
                        // check for alias (a.cre_dtim) and strip it out
                        if ($pos = strpos($parts[0], '.'))
                            $parts[0] = substr($parts[0], $pos + 1);

                        $metadata['sortInfo'] = array(
                            'field' => $parts[0],
                            'direction' => $parts[1],
                        );
                    }
                }
            }
            if (isset($start[0])) {
                $metadata['start'] = $start[0];
            }
            if (isset($limit[0])) {
                $metadata['limit'] = $limit[0];
            }
        }

        return $metadata;
    }


    /**
     * Determines the field definitions for results that will be returned by a
     * query.  Will not include custom SQL queries in the "select" clause.
     *
     * @param AIR2_Query $q (reference) the query object to search
     * @return array list of field definitions
     */
    protected function _get_fields(&$q) {
        $fields = array();

        $root_alias = $q->getRootAlias();

        // valid parents will be related to the root through has_one's
        $valid_parents = array($root_alias => null);

        // get the components from the query object
        $comps = $q->getQueryComponents();
        foreach ($comps as $alias => $info) {
            $table = $info['table'];
            $cols = $table->getColumns();

            if ($alias == $root_alias) {
                // is root --- add all local fields!
                foreach ($cols as $name => $def) {
                    if (!$this->_column_should_remove($name))
                        $fields []= air2_column_def($name, $def);
                }
            }
            // items directly related to the root
            elseif (isset($info['parent']) && $info['parent'] == $root_alias) {
                $rel_alias = $info['relation']->getAlias();

                // determine if we should flatten the relation
                if (in_array($rel_alias, $this->flatten)) {
                    foreach ($cols as $name => $def) {
                        $new_name = "$rel_alias:$name";
                        if (!$this->_column_should_remove($new_name)) {
                            $fields []= air2_column_def($new_name, $def);
                        }
                    }

                    // since we don't unset the original relation, add it too
                    $fields []= air2_column_def($rel_alias);
                }
                else {
                    // don't flatten
                    $fields []= air2_column_def($rel_alias);
                }
            }

            // NOTE: the following code isn't working, because mapping really
            // messes with JsonStores.  For now, revert to calling everything
            // a type "array" or type "object".  (with previous 'elseif')
            /*else {
                // check what kind of relation this is
                if ($info['relation']->getType() == Doctrine_Relation::ONE) {
                    if (array_key_exists($info['parent'], $valid_parents)) {
                        // valid parent --- build the mapping
                        if ($info['parent'] == $root_alias) {
                            $mapping = $info['relation']->getAlias();
                        }
                        else {
                            $mapping = $valid_parents[$info['parent']].'.'
                                      .$info['relation']->getAlias();
                        }
                        $valid_parents[$alias] = $mapping;

                        foreach ($cols as $name => $def) {
                            if (!$this->_column_should_remove($name))
                                $fields []= air2_column_def($name, $def, $mapping);
                        }
                    }
                }
                elseif ($info['relation']->getType() == Doctrine_Relation::MANY) {
                    if (array_key_exists($info['parent'], $valid_parents)) {
                        // valid parent --- build the mapping
                        $list_name = $info['relation']->getAlias();

                        $mapping = null;
                        if ($info['parent'] != $root_alias) {
                            $mapping = $valid_parents[$info['parent']];
                        }
                        // array metadata not supported ... just add the mapping
                        $fields []= air2_column_def($list_name, null, $mapping);
                    }
                }
            }*/
        }

        // add any extra field defs
        foreach ($this->extra_field_defs as $def) {
            $fields []= $def;
        }

        return $fields;
    }


    /**
     * Helper function to determine if a column should be excluded from results,
     * based on naming conventions, the column blacklist and whitelist.
     *
     * @param string  $colname
     * @return boolean if the column should be removed from any results
     */
    protected function _column_should_remove($colname) {
        if (in_array($colname, $this->column_whitelist)) {
            return false; // don't remove
        }
        elseif (in_array($colname, $this->column_blacklist)) {
            return true; // remove
        }
        elseif ( preg_match('/_cre_user$/', $colname) ||
            preg_match('/_upd_user$/', $colname) ||
            preg_match('/_password$/', $colname) ||
            preg_match('/_id$/', $colname) ) {
            return true; // remove
        }
        return false;
    }


}
